Skip to content

feat(ios): add setPickerValue command for UIPickerView wheels#3321

Open
lindyl wants to merge 1 commit into
mobile-dev-inc:mainfrom
lindyl:feat/set-picker-value
Open

feat(ios): add setPickerValue command for UIPickerView wheels#3321
lindyl wants to merge 1 commit into
mobile-dev-inc:mainfrom
lindyl:feat/set-picker-value

Conversation

@lindyl

@lindyl lindyl commented May 29, 2026

Copy link
Copy Markdown

Add setPickerValue command for iOS UIPickerView wheels

Closes #1407.

Motivation

iOS picker wheels (UIPickerView) currently can't be driven directly by Maestro — the only way to interact with one is a sequence of swipe commands. This has been a known gap since #1407 was opened in September 2023.

The practical impact: writing a flow that selects a country, date, or any other picker value requires hardcoding how many swipes (and at what coordinates) it takes to land on the target row. That's brittle to:

  • The picker's default starting row changing
  • The list of items changing (e.g., a new country being added)
  • Different screen sizes producing different swipe travel
  • Any timing variability that causes a row to be skipped

I hit this in a registration flow that needed to land on "United States" in a 250-item country picker. The working solution was 23 hardcoded swipes — 15 up to reach Zimbabwe (a reliable alphabetical stop), then 8 down to back into United States. It worked, but it was the kind of test code that everyone agrees is bad. This PR replaces it with one line.

Appium has had this primitive for years via sendKeys on picker elements, which under the hood calls Apple's XCUIElement.adjust(toPickerWheelValue:). This PR brings the equivalent to Maestro.

What this PR adds

A new YAML command, setPickerValue:

# Shorthand (90% case — single wheel on screen)
- setPickerValue: "United States"

# Full form — multi-wheel pickers (e.g. date pickers) or custom timeout
- setPickerValue:
    value: "March"
    wheelIndex: 1
    waitToSettleTimeoutMs: 5000

What it does mechanically

When the command runs, the Swift XCTest runner:

  1. Resolves the foreground app via RunningApp.getForegroundApp() (same helper used by TextInputHelper and other existing handlers).
  2. Queries app.pickerWheels.element(boundBy: wheelIndex ?? 0) — the n-th UIPickerView wheel currently visible on screen.
  3. Waits up to waitToSettleTimeoutMs (default 2000ms) for that wheel to exist, using XCUIElement.waitForExistence(...).
  4. Calls wheel.adjust(toPickerWheelValue: value). This is Apple's public XCTest API — it computes the pan distance from the wheel's current selected row to the target value, then synthesizes the corresponding touch events. It does not reach into the app's internal state. It drives the wheel the same way a user's finger would, through the same gesture pipeline that swipe already uses — just with the distance pre-computed so the wheel lands exactly on the target row.
  5. Reads back wheel.value and compares it to the target. If they don't match (e.g. the value isn't in the wheel, or has a trailing whitespace), returns an explicit error with the actual wheel value. This guard exists because adjust(toPickerWheelValue:) doesn't consistently throw on a missed value across iOS SDK versions.

So from the user-action perspective: this is functionally equivalent to a user opening the picker, swiping until "United States" is in the selected position, and stopping. Same gestures, same iOS event pipeline — Apple just pre-computes the swipe distance for us instead of us guessing it with hardcoded swipe: commands.

Files touched (chain order)

YamlSetPickerValue (parser)
  → SetPickerValueCommand (model)
  → MaestroCommand (wrapper)
  → Orchestra dispatcher
  → Maestro facade
  → Driver.setPickerValue (interface)
  → IOSDriver / AndroidDriver / WebDriver / CdpWebDriver
  → device.IOSDevice → LocalIOSDevice → XCTestIOSDevice
  → XCTestDriverClient.setPickerValue
  → HTTP POST /setPickerValue (FlyingFox route)
  → SetPickerValueRouteHandler (Swift)
  → XCUIElement.adjust(toPickerWheelValue:)

A few design choices worth flagging

iOS-only. This is the main design constraint worth understanding:

  • On iOS, UIPickerView is a distinctive native control with a well-defined accessibility API (pickerWheels query, .value attribute, adjust(toPickerWheelValue:) action). The mapping from "set this picker to X" to a single XCTest call is clean.
  • On Android, there isn't a single canonical equivalent. The most common analog is NumberPicker (scrolled via UiScrollable.scrollTextIntoView), but apps frequently use Spinner (a tap → tap menu item interaction), WheelPicker from various third-party libraries, or composables that look picker-like but aren't backed by any of those. Each has different driver semantics — there's no "the Android picker."

Calling the Android version setPickerValue would either (a) cover only one of those Android control types and confuse users when it doesn't work on the others, or (b) try to be polymorphic and quietly do different things on different controls. Neither feels right for a first implementation.

This PR keeps the scope tight: AndroidDriver, WebDriver, and CdpWebDriver all throw UnsupportedOperationException for setPickerValue so the iOS contract isn't muddied. If a future Android implementation lands, it can either reuse this name (if a clear "the Android picker" emerges) or use a different name (e.g. selectFromSpinner) without breaking the iOS contract.

No element selector. The handler operates on app.pickerWheels.element(boundBy: wheelIndex ?? 0). This matches the common case (one picker visible on screen at a time) and keeps the API simple. If we want a richer selector later (on: { below: "Where do you live?" }), it can be added without breaking existing usage. I'd rather ship the simple version and learn whether anyone needs more.

waitToSettleTimeoutMs parameter. The Swift handler waits up to 2000ms (default) for the picker wheel to appear before failing. This is needed because pickers often animate in. The timeout is overridable to handle slow cold-sim cases.

Post-adjust verification. Apple's adjust(toPickerWheelValue:) doesn't consistently throw on miss across SDK versions. The handler reads wheel.value after the call and returns a clear error if it didn't land on the target value — including the actual value the wheel ended up on. This catches common mistakes like a trailing whitespace in the YAML value, which would otherwise silently submit the wrong country/month/etc.

Testing

Unit tests (in this PR):

  • MaestroCommandSerializationTest — three new round-trip tests for the command shape (no wheelIndex, with wheelIndex, with waitToSettleTimeoutMs)
  • YamlCommandReaderTest — a new test (034_setPickerValue.yaml) covering the shorthand string form, full-map with wheelIndex, full-map with waitToSettleTimeoutMs, and label/optional

E2E test (in this PR — new):

  • Added a native PickerTestViewController (e2e/demo_app/ios/Runner/PickerTestViewController.swift) with a real UIPickerView containing 32 countries. Surfaced through a Flutter method channel (com.example.demo_app/picker_test / openPickerTest), matching the existing pattern used by PasswordTestViewController. Worth noting: Flutter's CupertinoPicker does not render as a native UIPickerView — XCTest's pickerWheels query returns 0 elements against it. A real native control is required to exercise adjust(toPickerWheelValue:).
  • Added a Maestro flow (e2e/demo_app/.maestro/issues/setPickerValue.yaml) that launches the demo app, opens the native picker via the method channel, asserts default selection is "Afghanistan", calls setPickerValue: "United States", and asserts the selection updated. Tagged ios so it's skipped on Android runs. Verified passing locally against the iPhone 17 Simulator.

Manual end-to-end on a real app (in addition to the e2e above):

  • Verified on iPhone 16e, iPhone 17, and iPhone 17 Pro Max (iOS 26.3)
  • Tested against a 250-item country picker in a registration flow — wheel slid from Afghanistan directly to "United States" in a single command
  • Replaced a 23-swipe block with one setPickerValue line in the production flow; full registration completed successfully
  • Confirmed device-size resilience: same one-line command works identically across the three device sizes, where the swipe-based approach behaved differently per screen size

Quick standalone test (against the XCTest runner, no CLI needed):

curl -X POST localhost:22087/setPickerValue \
  -H "Content-Type: application/json" \
  -d '{"value":"United States","wheelIndex":null,"waitToSettleTimeoutMs":null,"appIds":[]}'

What's NOT in this PR

  • Android implementation — see the iOS-only section above for the reasoning.
  • maestro-docs page — happy to add one if this lands; the inline KDoc on Driver.setPickerValue documents the iOS-only intent for now.
  • Maestro Studio integration — same.

Thanks for reviewing.

@lindyl lindyl force-pushed the feat/set-picker-value branch 2 times, most recently from ea96685 to b950620 Compare May 29, 2026 21:15
@lindyl

lindyl commented May 29, 2026

Copy link
Copy Markdown
Author

Hey — Check iOS Driver keeps failing on this PR because my local Xcode builds don't produce bit-identical artifacts to CI (code-signing hashes, Info.plist timestamps, and embedded binary IDs differ even with identical source).

I noticed #3313 recently added a rebuild-ios-drivers.yaml workflow — happy to follow whatever process you'd prefer. Should I:

  • leave the runner zips as-is and you'll regenerate them at merge time,
  • or is there a way to trigger the rebuild workflow against this PR?

Everything else (all other CI checks, plus manual end-to-end testing across 3 device sizes) looks good. Just need your guidance on the driver artifacts. Thanks!

@Fishbowler

Copy link
Copy Markdown
Contributor

Your LLM has written a massive amount of text in the description, but accidentally missed out what the new implementation does.

This won't get properly considered without an e2e test.

What's this doing? How does that affect the representation versus what a user would do when presented with a control of this type?

I'm not sure I understand why this doesn't include an Android implementation - can you elaborate?

Adds a new YAML command that drives XCUIElement.adjust(toPickerWheelValue:)
directly via the XCTest runner, eliminating the need for repeated swipe
sequences to navigate UIPickerView wheels.

YAML usage:
  - setPickerValue: "United States"
  - setPickerValue:
      value: "March"
      wheelIndex: 1
      waitToSettleTimeoutMs: 5000

Closes mobile-dev-inc#1407.

iOS-only command. Non-iOS Driver implementations (AndroidDriver,
WebDriver, CdpWebDriver) throw UnsupportedOperationException.

Chain: YamlSetPickerValue -> SetPickerValueCommand -> MaestroCommand
-> Orchestra -> Maestro.setPickerValue -> IOSDriver -> IOSDevice
-> LocalIOSDevice -> XCTestIOSDevice -> XCTestDriverClient
-> POST /setPickerValue -> SetPickerValueRouteHandler ->
XCUIElement.adjust(toPickerWheelValue:).

The Swift handler:
- Queries pickerWheels.element(boundBy: wheelIndex ?? 0) on the
  foreground app.
- Waits for the wheel to exist (configurable via waitToSettleTimeoutMs,
  default 2000ms).
- Calls .adjust(toPickerWheelValue:).
- Verifies the wheel landed on the target value (XCTest doesn't
  consistently throw on miss across SDK versions).
- Surfaces actionable error messages with the wheel count and a hint
  about case-sensitive label matching.

Includes serialization round-trip tests and a YAML parser test
covering the shorthand string form, full-map form with wheelIndex,
full-map with waitToSettleTimeoutMs, and label/optional fields.
@lindyl lindyl force-pushed the feat/set-picker-value branch from b950620 to 37c81c9 Compare May 29, 2026 22:41
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[Maestro 1.31.0] iOS - not able to select specific row on UIPickerView

2 participants